#!/usr/bin/perl
# cybozu2ical: Convert Cybozu Office calendar into iCalendar format
#
# $Id: cybozu2ical 560 2009-02-05 10:22:38Z ogawa $

use strict;
use lib 'lib';

use Encode qw( decode_utf8 encode );
use Data::ICal;
use Data::ICal::Entry::Event;
use Data::ICal::Entry::TimeZone;
use Data::ICal::Entry::TimeZone::Standard;
use DateTime;
use WWW::CybozuOffice6::Calendar;
use URI;
use Pod::Usage;
use Getopt::Long;

our $VERSION = '0.33';

###
### TRICK (stop escaping for 'exdate' property)
###
*Data::ICal::Property::_value_as_string = sub {
    my $self  = shift;
    my $key   = shift;
    my $value = defined( $self->value() ) ? $self->value() : '';

    unless ( $self->vcal10 ) {
        my $lc_key = lc($key);
        $value =~ s/\\/\\/gs;
        $value =~ s/\Q;/\\;/gs
          unless ( $lc_key eq 'rrule' || $lc_key eq 'exdate' );
        $value =~ s/,/\\,/gs
          unless ( $lc_key eq 'rrule' || $lc_key eq 'exdate' );
        $value =~ s/\n/\\n/gs;
        $value =~ s/\\N/\\N/gs;
    }

    return $value;

};

###
### Utility subroutines
###
sub to_icaldate {
    my ( $dt, $is_full_day ) = @_;
    $is_full_day
      ? $dt->ymd('')
      : $dt->ymd('') . 'T'
      . $dt->hms('')
      . ( $dt->time_zone->is_utc ? 'Z' : '' );
}

sub encode_string {
    my ( $enc, $text ) = @_;
    if ( $enc eq 'ncr' ) {
        $text =~ s/(\P{ASCII})/sprintf("&#%d;", ord($1))/eg;
    }
    else {
        $text = encode( $enc, $text );
    }
    $text;
}

sub read_yaml {
    my $file = shift;
    my $yaml;
    if ( eval('require YAML::Tiny') ) {
        ($yaml) = YAML::Tiny::LoadFile($file);
    }
    elsif ( eval('require YAML') ) {
        ($yaml) = YAML::LoadFile($file);
    }
    if ($@) {
        die "Faild to read yaml file: $@";
    }
    $yaml;
}

sub generate_vcalendar {
    my ( $cal, $param ) = @_;

    my $time_zone = $param->{time_zone} || 'Asia/Tokyo';
    my $tzname    = $param->{tzname}    || 'JST';
    my $std       = DateTime->new(
        year      => 1970,
        month     => 1,
        day       => 1,
        hour      => 0,
        minute    => 0,
        second    => 0,
        time_zone => $time_zone
    );
    my $offset = DateTime::TimeZone::offset_as_string( $std->offset )
      || '+0900';

    # Generate a root 'calendar' object
    my $vcalendar = Data::ICal->new();
    $vcalendar->add_properties(
        prodid   => "-//as-is.net/Cybozu2ICal $VERSION//EN",
        calscale => 'GREGORIAN',
        method   => 'PUBLISH',
        $param->{calname} ? ( 'X-WR-CALNAME' => $param->{calname} ) : (),
        'X-WR-TIMEZONE' => $time_zone
    );

    # Generate a 'timezone entry' and append it to the calendar
    my $vtimezone = Data::ICal::Entry::TimeZone->new();
    $vtimezone->add_properties( tzid => $time_zone );

    # probably we need to support the Daylight Saving Time,
    # but not yet implemented.
    my $standard = Data::ICal::Entry::TimeZone::Standard->new();
    $standard->add_properties(
        tzoffsetfrom => $offset,
        tzoffsetto   => $offset,
        tzname       => $tzname,
        dtstart      => to_icaldate($std)
    );
    $vtimezone->add_entry($standard);

    $vcalendar->add_entry($vtimezone);

    # For each items, generate an 'event entry' and append it to the calendar
    for my $item ( $cal->get_items() ) {
        my $vevent = Data::ICal::Entry::Event->new();
        my %args   = (
            summary     => decode_utf8( $item->summary ),
            description => decode_utf8( $item->description ),
            created     => to_icaldate( $item->created ),
            dtstamp     => to_icaldate( $item->modified ),
        );

        if ( $item->is_full_day ) {
            $args{dtstart} =
              [ to_icaldate( $item->start, 1 ), { VALUE => 'DATE' } ];
            $args{dtend} =
              [ to_icaldate( $item->end, 1 ), { VALUE => 'DATE' } ];
        }
        else {
            $args{dtstart} =
              [ to_icaldate( $item->start, 0 ), { TZID => $time_zone } ];
            $args{dtend} =
              [ to_icaldate( $item->end, 0 ), { TZID => $time_zone } ];
        }

        # handle frequency
        if ( $item->can('rrule') ) {

            # rrule
            my %rrule = %{ $item->rrule };
            $rrule{UNTIL} = to_icaldate( $rrule{UNTIL}, $item->is_full_day )
              if $rrule{UNTIL};
            $rrule{WKST} = 'SU'
              if $param->{'compat-google-calendar'};

            my @rrule_list;
            for (qw(FREQ COUNT INTERVAL BYMONTH BYMONTHDAY WKST BYDAY UNTIL)) {
                push @rrule_list, $_ . '=' . $rrule{$_}
                  if exists $rrule{$_};
            }
            $args{rrule} = join ';', @rrule_list;

            # exdate
            if ( $item->exdates ) {
                if ( $item->is_full_day ) {
                    my $exdate = join ',',
                      map { to_icaldate( $_, 1 ) } $item->exdates;
                    $args{exdate} = [ $exdate, { VALUE => 'DATE' } ];
                }
                else {
                    my $exdate = join ',',
                      map { to_icaldate( $_, 0 ) } $item->exdates;
                    $args{exdate} = [ $exdate, { TZID => $time_zone } ];
                }
            }
        }

        # set uid (recommended to be the identical syntax to RFC822)
        $args{uid} =
          $item->id . '@' . ( URI->new( $cal->url )->host || 'localhost' )
          if $param->{uid};

        $args{url} = $cal->url . '?page=ScheduleView&EID=' . $item->id
          if $param->{url};

        # $args{class}  = $item->is_private ? 'PRIVATE'     : 'PUBLIC';
        # $args{transp} = $item->is_private ? 'TRANSPARENT' : 'OPAQUE';

        $args{comment} = decode_utf8( $item->comment )
          if $param->{debug} && $item->comment;

        $vevent->add_properties(%args);
        $vcalendar->add_entry($vevent);
    }

    $vcalendar;
}

sub update_vcalendar {
    my ( $vcalendar, $filename, $param ) = @_;

    return $vcalendar unless -e $filename;
    return $vcalendar unless $param->{uid};
    open my $fh, $filename or die "Failed to read $filename";
    my @lines = <$fh>;
    close $fh;
    my $vcal_orig = Data::ICal->new( data => decode_utf8( join( '', @lines ) ) )
      or return $vcalendar;

    my %event_entries;
    my @common_entries;
    for my $entry ( @{ $vcalendar->{entries} } ) {
        if ( $entry->isa('Data::ICal::Entry::Event') ) {
            my $uid_prop = $entry->properties->{uid}->[0]
              or next;
            my $uid = $uid_prop->value
              or next;
            $event_entries{$uid} = $entry;
        }
        else {
            push @common_entries, $entry;
        }
    }
    my $dt = DateTime->today;
    if ( $param->{date_range} ) {
        $dt->subtract( days => $param->{date_range} );
        $dt->truncate( to => 'day' );
    }
    else {
        $dt->truncate( to => 'year' );
    }
    for my $entry ( @{ $vcal_orig->{entries} } ) {
        if ( $entry->isa('Data::ICal::Entry::Event') ) {
            my $uid_prop = $entry->properties->{uid}->[0]
              or next;
            my $uid = $uid_prop->value
              or next;
            unless ( exists $event_entries{$uid} ) {
                my $dt_prop = $entry->properties->{dtend}->[0]
                  || $entry->properties->{dtstart}->[0];
                if ( $dt_prop && $dt_prop->value =~ m/^(\d{4})(\d{2})(\d{2})/ )
                {
                    my $duration =
                      DateTime->new( year => $1, month => $2, day => $3 ) - $dt;
                    $entry->add_properties( status => 'CANCELLED' )
                      if $duration->is_positive;
                }
                $event_entries{$uid} = $entry;
            }
        }
    }
    $vcalendar->{entries} = [ @common_entries, values %event_entries ];
    $vcalendar;
}

###
### Main part
###

# Handle command-line options
my %opt = (
    conf                     => 'config.yaml',
    'compat-google-calendar' => 0,
    'uid'                    => 1,
    'url'                    => 0,
);
GetOptions( \%opt, 'output=s', 'update=s', 'conf=s', 'compat-google-calendar',
    'debug', 'input-csv=s', 'output-csv=s', 'help', 'uid!', 'url!' )
  or pod2usage(2);
pod2usage(1) if $opt{help};

# Read configuration file
my $cfg = read_yaml( $opt{conf} );

# Obtain Cybozu Office 6/7 Calendar items
my $cal = WWW::CybozuOffice6::Calendar->new(%$cfg);

if ( $opt{'input-csv'} ) {
    $cal->read_from_csv_file( $opt{'input-csv'} )
      or die "Failed to read CSV file: $opt{'input-csv'}";
}
else {
    $cal->request()
      or die "Failed to get Cybozu Office 6 Calendar";
}

# Output the calendar CSV for debugging
if ( $opt{'output-csv'} ) {
    local *FH;
    open FH, ">$opt{'output-csv'}" or die "Failed to write $opt{'output-csv'}";
    print FH "$_\n" for $cal->response;
    close FH;
}

# Generate a Data::ICal from a WWW::CybozuOffice6::Calendar
my $vcalendar = generate_vcalendar( $cal, { %$cfg, %opt } );

if ( $opt{update} ) {
    $vcalendar = update_vcalendar( $vcalendar, $opt{update}, { %$cfg, %opt } );
}

# Outputs the calendar as a string
my $fh;
if ( my $filename = $opt{output} || $opt{update} ) {
    open $fh, ">$filename" or die "Failed to write $filename";
}
else {
    $fh = *STDOUT;
}
print $fh encode_string( $cfg->{output_encoding} || 'utf8',
    $vcalendar->as_string );
close $fh;

1;
__END__

=head1 NAME

cybozu2ical - Convert Cybozu Office calendar into iCalendar format

=head1 SYNOPSIS

  % cybozu2ical
  % cybozu2ical --conf /path/to/config.yaml

=head1 DESCRIPTION

C<cybozu2ical> is a command line application that fetches calendar
items from Cybozu Office 6 or later, and converts them into an
iCalendar file.  It allows you to easily integrate the Cybozu Calendar
into iCalendar-enabled Calendar applications, such as Microsoft
Outlook, Apple iCal, and of course, Google Calendar.

You can run this via crontab, for example, every 1 hour.

=head1 REQUIREMENT

This application requires perl 5.8.0 with following Perl modules
installed on your box.

=over 4

=item WWW::CybozuOffice6::Calendar

=item Text::CSV_XS or Text::CSV

=item DateTime

=item LWP::UserAgent

=item Class::Accessor::Fast

=item Data::ICal

=item YAML or YAML::Tiny

=back

=head1 OPTIONS

=over 4

=item --output /path/to/output.ics

Specify the output file.  By default, this application outputs to
STDOUT.

=item --update /path/to/output.ics

Specify the output file.  Instead of overwriting an iCalendar file,
this option allows you to merge the original file with newly obtained
CybozuOffice 6 events.

=item --conf /path/to/config.yaml

Specify the configuration file.  By default, C<config.yaml> in the
current directory will be used.

=item --compat-google-calendar

Output an iCalendar file compatible with Google Calendar.

=item --debug

Output CSV data in a COMMENT field of each events.  It's just for
debugging.

=item --input-csv /path/to/input.csv

Instead of requesting Cybozu Office 6 server, read from a local CSV
file.

=item --output-csv /path/to/output.csv

Specify the output CSV file for debugging.

=item --uid, --no-uid

Enable/Disable UID fields of the iCalendar file. (Default: Enabled)

=item --url, --no-url

Enable/Disable URL fields of the iCalendar file. (Default: Disabled)

=item --help

Print out this message.

=back

=head1 CONFIGURATION

The distributions includes a sample configuration file
C<config.yaml.sample>. You can rename it to C<config.yaml> and
configure C<cybozu2ical>.

=over 4

=item cybozu_url

Set the URL of your Cybozu Office 6 or later.

=item calname

Set the calendar name string. iCalendar applications which properly
handle X-WR-CALNAME header, is expected to use this string as a
calendar name.

=item username, userid

Set your username or userid for Cybozu Office.

=item password

Set your password for Cybozu Office.

=item time_zone

Set the timezone of your Cybozu Office (e.g., Asia/Tokyo).

=item tzname

Set the short timezone name of your Cybozu Office (e.g., JST).

=item input_encoding

Set the charset of Cybozu Office. By default, C<input_encoding> is
"shiftjis".

=item output_encoding

Set the charset of the iCalendar file.  By default, C<output_encoding>
is "utf8".  If you need to output multibyte strings as Numeric
Character References for some reason, set C<output_encoding> to "ncr".

=item calendar_driver

Set the calendar driver that C<cybozu2ical> employs.  By default,
C<ApiCalendar> is used as C<calendar_driver>.

Currently, C<ApiCalendar> and C<SyncCalendar> drivers are shipped with
C<cybozu2ical>.  If you are using Cybozu Office 6, C<SyncCalendar> is
strongly recommended.  Otherwise, you have to use C<ApiCalendar>.

=item date_range (experimental)

Set the date range of calendar, which means C<cybozu2ical> handles
calendar items from N days before to the end.  By default,
C<cybozu2ical> handles calendar items from the beginning of the
current year to the end.

=back

=head1 DEVELOPMENT

The development version is always available from the following
subversion repository:

  http://code.as-is.net/svn/public/cybozu2ical/trunk/

You can browse the files via Trac from the following:

  http://code.as-is.net/public/browser/cybozu2ical/trunk/

Any comments, suggestions, or patches are welcome.

=head1 LICENSE

Copyright (c) 2008 Hirotaka Ogawa E<lt>hirotaka.ogawa at gmail.comE<gt>.
All rights reserved.

This library is free software; you can redistribute it and/or modify
it under the terms of either:
       
   a) the GNU General Public License as published by the Free Software
      Foundation; either version 1, or (at your option) any later
      version, or
                         
   b) the "Artistic License" which comes with Perl.

=cut
